Un bug en el código se pilla en un code review. Un problema de servidor salta en el panel de monitorización antes de que nadie se entere. Pero un problema de base de datos casi nunca avisa. Se queda ahí, agazapado, hasta que la tabla crece, entra tráfico real o alguien lanza la query equivocada un viernes a las seis de la tarde.
La mayoría de estos fallos no son errores de sintaxis. Son decisiones de diseño que parecían razonables el primer día y que se convierten en un problema serio dos años después, cuando ya no es tan fácil deshacerlas. Repasamos quince de los más habituales, con ejemplos en SQL, PHP y algún caso real que ya ha pasado factura a empresas conocidas.
1. Usar SELECT * en el código de la aplicación
SELECT * es cómodo de escribir y una mala idea a la larga. El problema no es solo de rendimiento, aunque también: si la tabla tiene columnas de texto largo o BLOBs que no vas a usar, cada consulta arrastra más datos de los necesarios por la red y ocupa más memoria en el cliente.
El problema gordo es otro: acopla tu código a la forma exacta de la tabla en ese momento. Si un compañero añade una columna nueva o renombra una existente, cualquier fetch_assoc() o mapeo con un ORM que dependa del orden o del nombre de las columnas puede romperse sin que nadie haya tocado esa parte del código.
-- Mal: arrastra columnas que no necesitas y te ata al esquema actual SELECT * FROM pedidos WHERE id_cliente = 482; -- Bien: explícito y a prueba de cambios en la tabla SELECT id, total, estado, fecha_creacion FROM pedidos WHERE id_cliente = 482;
En PHP con PDO, además, seleccionar solo lo necesario deja claro qué espera el código más abajo:
$stmt = $pdo->prepare(
'SELECT id, total, estado FROM pedidos WHERE id_cliente = :id'
);
$stmt->execute(['id' => $idCliente]);
$pedidos = $stmt->fetchAll(PDO::FETCH_ASSOC);
2. Guardar fechas sin zona horaria
Este es de los que no duelen hasta que la aplicación tiene usuarios en más de un país, o hasta que el servidor cambia de zona horaria en una migración. Un campo DATETIME en MySQL o TIMESTAMP sin WITH TIME ZONE en PostgreSQL guarda un valor como 2026-09-27 10:00:00 sin decir en ningún sitio si eso es hora de Madrid, hora de Ciudad de México o UTC.
El día que despliegas la app en un servidor con otra zona horaria, o que un usuario en Argentina ve una fecha de entrega calculada con la hora del servidor en Madrid, ya tienes un bug que solo aparece en producción y que es un infierno de reproducir en local.
-- MySQL: guarda siempre en UTC y conserva el dato de zona en la app
CREATE TABLE pedidos (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
creado_en TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Con el servidor configurado en UTC (time_zone = '+00:00')
-- PostgreSQL: usa directamente el tipo con zona
CREATE TABLE pedidos (
id BIGSERIAL PRIMARY KEY,
creado_en TIMESTAMPTZ NOT NULL DEFAULT now()
);
La regla que funciona casi siempre: guarda en UTC en la base de datos y convierte a la hora local solo en la capa de presentación, nunca al revés.
3. Meterlo todo en una columna JSON
Con MySQL 5.7+ y el tipo JSONB de PostgreSQL, es tentador resolver cualquier duda de modelado metiendo un JSON y ya está. El problema aparece cuando ese JSON contiene datos por los que la aplicación necesita filtrar, ordenar o hacer JOIN a diario.
-- Mal para datos que se consultan constantemente
CREATE TABLE usuarios (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
datos JSON
);
-- datos = {"nombre": "Marta", "email": "[email protected]", "plan": "pro"}
-- Filtrar por plan obliga a esto (lento, sin índice real salvo que crees uno funcional):
SELECT * FROM usuarios WHERE JSON_EXTRACT(datos, '$.plan') = 'pro';
La columna JSON tiene sentido para lo que de verdad varía de un registro a otro: preferencias de notificaciones, metadatos de un formulario configurable, el payload de un evento de auditoría. Para el email, el plan de suscripción o cualquier campo con un UNIQUE o un NOT NULL detrás, mejor una columna normal:
CREATE TABLE usuarios (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
plan ENUM('free','pro','business') NOT NULL DEFAULT 'free',
preferencias JSON NULL
);
4. Borrar filas de verdad con DELETE
Un DELETE FROM pedidos WHERE id = 91 es irreversible en el momento en que se confirma la transacción. Si detrás hay un bug en la aplicación, un WHERE mal construido o alguien que confunde el entorno de producción con el de pruebas, la única salida es restaurar un backup, con la pérdida de datos posteriores que eso implica.
El borrado lógico no es la solución para todo, pero en tablas con valor de negocio (pedidos, facturas, usuarios) suele compensar:
ALTER TABLE pedidos ADD COLUMN eliminado_en DATETIME NULL; -- En vez de DELETE UPDATE pedidos SET eliminado_en = NOW() WHERE id = 91; -- Las consultas normales filtran los borrados SELECT * FROM pedidos WHERE eliminado_en IS NULL; -- Un índice ayuda a que ese filtro no salga caro CREATE INDEX idx_pedidos_eliminado ON pedidos (eliminado_en);
El coste es que cada consulta tiene que acordarse de excluir lo borrado y que la tabla crece sin límite si nunca se purga de verdad. Por eso no tiene sentido aplicarlo a una tabla de logs o de sesiones temporales: ahí el DELETE normal, con su índice y su rutina de limpieza, sigue siendo lo correcto.
5. Caer en el problema N+1
Es el clásico de cualquier ORM: Eloquent, Doctrine, SQLAlchemy o el que toque. Pides una lista de 50 entradas de un blog y, para pintar el nombre del autor de cada una, el ORM lanza 50 consultas adicionales sin que lo hayas pedido explícitamente.
// Doctrine sin eager loading: 1 query para los posts + 1 por cada autor
$posts = $repositorioPosts->findAll(); // 1 query
foreach ($posts as $post) {
echo $post->getAutor()->getNombre(); // dispara 1 query por post
}
Con 50 registros no se nota. Con 5.000, cada carga de página lanza 5.001 consultas y el tiempo de respuesta se dispara aunque cada consulta individual sea rapidísima: la latencia de red hacia la base de datos, multiplicada por miles, es la que mata.
-- La alternativa: una sola consulta con JOIN SELECT p.id, p.titulo, u.nombre AS autor FROM posts p JOIN usuarios u ON u.id = p.id_usuario ORDER BY p.fecha_publicacion DESC LIMIT 50;
// Doctrine con eager loading explícito
$posts = $repositorioPosts->createQueryBuilder('p')
->addSelect('a')
->join('p.autor', 'a')
->getQuery()->getResult(); // 1 sola query
6. Añadir índices por probar suerte
El índice es la herramienta de rendimiento más malentendida que hay. Un índice acelera lecturas concretas, pero cada índice también se actualiza en cada INSERT, UPDATE y DELETE, y ocupa espacio en disco y en la caché de memoria del motor.
Una tabla con doce índices de los que solo tres se usan de verdad no es más rápida: es más lenta escribiendo y no gana nada leyendo. Antes de crear un índice hay que comprobar cómo lo está usando realmente el motor:
EXPLAIN ANALYZE SELECT * FROM usuarios WHERE email = '[email protected]';
En MySQL, la vista sys.schema_unused_indexes (con el Performance Schema activado) te dice qué índices lleva meses sin tocar nadie. En PostgreSQL, la consulta contra pg_stat_user_indexes hace lo mismo:
-- PostgreSQL: índices que no se han usado desde el último reinicio de estadísticas SELECT relname AS tabla, indexrelname AS indice, idx_scan FROM pg_stat_user_indexes WHERE idx_scan = 0 ORDER BY relname;
7. Normalizar hasta el extremo
La normalización evita duplicar datos y mantiene la integridad, pero llevada al límite convierte cualquier consulta en una cadena de cinco o seis JOIN. Cada JOIN adicional es trabajo extra para el planificador de consultas, y en tablas grandes el coste no es lineal.
-- Modelo hiper-normalizado para listar el carrito de un pedido SELECT p.id, pr.nombre, cat.nombre AS categoria, marca.nombre AS marca FROM pedido_lineas pl JOIN productos pr ON pr.id = pl.id_producto JOIN categorias cat ON cat.id = pr.id_categoria JOIN marcas marca ON marca.id = pr.id_marca JOIN pedidos p ON p.id = pl.id_pedido WHERE p.id = 1029;
Para una pantalla que se pinta constantemente y que se puede permitir datos con unos minutos de desfase (el listado de pedidos de un cliente, el feed de una red social, el catálogo de una tienda), guardar una versión desnormalizada, aunque sea con algo de duplicación, suele ser mejor negocio que optimizar el JOIN hasta el infinito. Instagram, por ejemplo, ha explicado en varias charlas técnicas que buena parte de su feed se sirve desde estructuras precomputadas en lugar de recalcular JOINs en cada petición.
8. No leer el plan de ejecución
Una query puede parecer inofensiva y aun así hacer un Seq Scan sobre diez millones de filas. La única forma de saberlo es preguntarle al motor, no adivinarlo:
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM pedidos WHERE id_cliente = 482 AND estado = 'pendiente';
El plan dice si se está usando un índice, cuántas filas espera el optimizador frente a las que hay en realidad (una diferencia grande ahí suele significar que las estadísticas de la tabla están desactualizadas), y en qué paso concreto se va el tiempo. Sin mirarlo, es fácil añadir un índice que no soluciona nada o pasarse horas optimizando la parte de la query que no era el cuello de botella.
9. No poner reglas de integridad en la base de datos
Validar en el backend está bien, pero la base de datos debería ser la última línea de defensa, no la aplicación. Un script de migración de datos, un microservicio nuevo que escribe en la misma tabla o una query lanzada a mano desde un cliente de SQL pueden saltarse cualquier validación que solo exista en el código PHP o Python.
CREATE TABLE usuarios (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
edad TINYINT UNSIGNED CHECK (edad >= 18),
saldo DECIMAL(10,2) NOT NULL DEFAULT 0 CHECK (saldo >= 0)
);
MySQL solo respeta los CHECK desde la versión 8.0.16; en versiones anteriores se aceptaban en la sintaxis pero se ignoraban en silencio, así que conviene comprobar la versión del motor antes de dar por hecho que esa restricción está funcionando.
10. Consultas sin límite ni timeout
Una query sin LIMIT funciona perfectamente en local, con la tabla de pruebas de doscientas filas. El día que un cliente concreto acumula tres millones de eventos, esa misma query intenta traerse los tres millones a memoria de golpe.
-- Peligroso en cuanto la tabla crece SELECT * FROM eventos WHERE id_usuario = 482; -- Con paginación por cursor, mucho más sano en tablas grandes SELECT * FROM eventos WHERE id_usuario = 482 AND fecha_creacion < '2026-09-20 00:00:00' ORDER BY fecha_creacion DESC LIMIT 50;
Conviene además poner un límite de tiempo por sesión para que una consulta lenta no bloquee recursos indefinidamente:
-- MySQL: aborta la sesión si una consulta SELECT tarda más de 5s SET SESSION MAX_EXECUTION_TIME = 5000; -- PostgreSQL: equivalente a nivel de sesión o de rol SET statement_timeout = '5s';
11. No usar transacciones para operaciones que van juntas
Si mover dinero de una cuenta a otra son dos UPDATE separados y el segundo falla por lo que sea (se cae la conexión, salta un deadlock, el proceso PHP muere por timeout), el primero ya se ha aplicado. Sin transacción, no hay manera limpia de deshacerlo.
START TRANSACTION; UPDATE cuentas SET saldo = saldo - 100 WHERE id = 1; UPDATE cuentas SET saldo = saldo + 100 WHERE id = 2; COMMIT; -- Si algo falla antes del COMMIT: ROLLBACK;
En PHP con PDO es igual de directo, y merece la pena envolverlo con manejo de errores real:
$pdo->beginTransaction();
try {
$pdo->prepare('UPDATE cuentas SET saldo = saldo - ? WHERE id = ?')
->execute([100, 1]);
$pdo->prepare('UPDATE cuentas SET saldo = saldo + ? WHERE id = ?')
->execute([100, 2]);
$pdo->commit();
} catch (Exception $e) {
$pdo->rollBack();
throw $e;
}
12. No entender los niveles de aislamiento
Dos transacciones que se ejecutan a la vez pueden pisarse sin que salte ningún error. El ejemplo típico: dos peticiones leen el mismo saldo, cada una calcula su resta por separado y la segunda en escribir sobrescribe el resultado de la primera, como si esta nunca hubiese ocurrido.
- Read Uncommitted: puede leer cambios de otra transacción que todavía ni se ha confirmado. Casi nunca es lo que se quiere.
- Read Committed: solo ve datos ya confirmados. Es el nivel por defecto en PostgreSQL y en Oracle.
- Repeatable Read: dentro de la misma transacción, una fila leída dos veces da siempre el mismo resultado. Es el valor por defecto en MySQL/InnoDB.
- Serializable: el más estricto; se comporta como si las transacciones se ejecutaran una detrás de otra. También el que más bloqueos y reintentos genera bajo carga.
-- Para una operación que de verdad necesita evitar condiciones de carrera SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; START TRANSACTION; -- ... COMMIT;
Subir el nivel de aislamiento a lo bruto para "ir sobre seguro" en todas partes suele traducirse en más deadlocks y peor rendimiento bajo concurrencia. La cuenta bancaria necesita SERIALIZABLE o un SELECT ... FOR UPDATE; el contador de visitas de un artículo, no.
13. No probar nunca la restauración de un backup
Tener backups automatizados no es lo mismo que tener backups que funcionan. El caso más citado en el sector sigue siendo el de GitLab en enero de 2017: durante una operación de mantenimiento, un ingeniero borró por error el directorio de datos de la base de datos de producción, y al intentar recuperarla descubrieron que varios de sus mecanismos de backup llevaban tiempo fallando en silencio, sin que ninguna alerta lo hubiera detectado. GitLab publicó el incidente con todo detalle en su momento, y sigue siendo lectura obligada para cualquiera que gestione una base de datos en producción.
pg_dump midb > backup.sql && echo "backup generado" # Eso no demuestra nada por sí solo. Lo que hay que comprobar es esto: createdb restauracion_prueba pg_restore -d restauracion_prueba backup.dump psql restauracion_prueba -c "SELECT count(*) FROM pedidos;"
Un backup que nunca se ha restaurado es una hipótesis, no una copia de seguridad. Restaurarlo de vez en cuando en un entorno aparte y comprobar que los datos están completos es la única forma de saber que, el día que haga falta de verdad, va a funcionar.
14. Tratar las conexiones a la base de datos como un recurso infinito
Cada conexión abierta consume memoria y un hilo o proceso en el motor de base de datos. Si la aplicación abre una conexión nueva por cada petición HTTP y el tráfico sube de golpe, es fácil chocar contra el límite de conexiones del servidor (max_connections en MySQL y PostgreSQL) mucho antes de que la CPU o el disco sean el problema.
-- Ver el límite configurado y las conexiones activas en MySQL SHOW VARIABLES LIKE 'max_connections'; SHOW STATUS LIKE 'Threads_connected';
La solución habitual es un pool de conexiones: PgBouncer para PostgreSQL, o el pool integrado en el driver o en el framework (Doctrine y muchos frameworks PHP-FPM reutilizan conexiones persistentes; en Node.js, librerías como mysql2 o pg incluyen su propio pool). El objetivo es que cientos de peticiones concurrentes compartan un número mucho menor de conexiones reales contra la base de datos, en vez de abrir una por cada una.
15. No contar con el retraso de las réplicas de lectura
Cuando una aplicación crece, es habitual separar las lecturas de las escrituras: las escrituras van al nodo primario y las lecturas se reparten entre una o varias réplicas para aliviar carga. El problema es que la replicación no es instantánea. Hay un margen, normalmente de milisegundos pero a veces de varios segundos si la réplica va cargada, entre que un dato se escribe en el primario y aparece en la réplica.
El bug clásico: un usuario rellena un formulario, la aplicación escribe en el primario y, justo después, redirige a una página que lee de la réplica para mostrar el resultado. Si la réplica todavía no ha recibido ese cambio, el usuario ve como si su acción no se hubiera guardado, cuando en realidad sí se guardó.
// Peligroso si el pool reparte automáticamente entre réplicas
$pdoEscritura->prepare('INSERT INTO comentarios (post_id, texto) VALUES (?, ?)')
->execute([$postId, $texto]);
header('Location: /post/' . $postId); // la siguiente petición puede leer de una réplica
// que todavía no tiene el comentario recién insertado
La solución no es dejar de usar réplicas, sino ser explícito sobre cuándo hay que leer del primario: justo después de una escritura relevante para el propio usuario que la hizo, o forzando lectura del primario en las operaciones donde la consistencia importa más que repartir carga. La mayoría de frameworks con soporte de réplicas (Laravel, por ejemplo) permiten fijar esa conexión "sticky" durante un tiempo tras cada escritura precisamente por este motivo.
La base de datos no perdona a medias
El código de la aplicación se puede desplegar de nuevo en cinco minutos si algo sale mal. Un esquema de base de datos ya en producción, con millones de filas y aplicaciones dependiendo de él, es mucho más caro de rehacer. Merece la pena diseñarlo bien desde el principio, y revisar estos puntos antes de que sea el tráfico real quien los saque a la luz.
Imagen: Pexels / panumas nikhomkhai
